Spring Security | Note-5

Spring Security Note-5


使用Spring Security开发基于表单的认证

介绍Spring Security的基本原理和核心概念;

如何利用Spring Security提供的开箱即用的功能快速开发基于用户名密码的登录;

如何扩展Spring Security的默认实现来满足个性化的需求;

深入了解Spring Security的源码实现;

如何向Spring Security中加入完全自定义的登录方式;

Spring Security核心功能:

认证(你是谁)、授权(你能干什么)、攻击防护(防止伪造身份)


Spring Security基本原理

当我们将前面学习中,关闭的Spring Security身份验证,重新打开时,进行默认身份验证的测试;

1
2
# spring-security
security.basic.enabled=true

当访问http://localhost:8060/user时候,通过user,Using default security password的方式,进行访问;

在没有进行任何配置的时候,Spring Security默认对所有请求和访问都进行了身份认证的拦截;

接下来,我们通过登录页面 & 表单认证的方式,进行身份的认证;

1
2
3
4
5
6
7
8
9
10
11
@Configuration
public class BroswerSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// 表单登录
http.formLogin().and()
.authorizeRequests() // 都需要认证
.anyRequest() // 任何请求
.authenticated(); // 认证后才能访问
}
}
讲解:

REST API(服务中的Controller);

Spring Security最核心的东西叫:Spring Security过滤器链;

所有的请求,都会经过过滤器链,响应也一致;

最核心的过滤器:Basic Authentication FilterUsernamePassword Authentication Filter等等;

这些过滤器作用在于检验你的请求中,是否存在可以通过过滤器需要认证的信息;

请求的过滤器经过认证后,在过滤器链最后一环叫:FilterSecurity Interceptor

它将通过我们的配置判断是否身份认证成功;

虽然进过身份认证,但是不存在权限,在FilterSecurity Interceptor之前,存在一个Exception Translation Filter去返回响应存在的异常,引导登录或授权等;

测试

在以上提到的过滤器和自定义的Controller中,通过打断点,Debugger的方式,进行一次源码的解析;

1.当访问服务http://localhost:8060/user 时,将进入到FilterSecurity Interceptor当中进行判断是否可以进行服务的请求,但是用于在配置当中,我们声明所有的请求都需要身份验证,此时将抛出异常;

2.抛出的异常

org.springframework.security.access.AccessDeniedException: Access is denied

将由Exception Translation Filter捕获,并且将重定向到一个登录的页面当中;

3.重定向到http://localhost:8060/login 之后,进行登录的请求操作;

4.此时将访问到UsernamePassword Authentication Filter进行认证;

5.登录请求之后,将再次跳转到FilterSecurity Interceptor进行请求,在这之间有一个http://localhost:8060/user的再次请求;

6.此时没有报异常,将成功请求;


自定义用户认证逻辑

处理用户信息获取逻辑(数据库)

用户信息获取的逻辑,被Spring Security封装在了UserDeatialService当中,里面只有一个方法;

1
2
3
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
1
2
3
4
5
6
7
8
9
10
11
@Component
public class MyUserDetailService implements UserDetailsService {
private Logger logger = LoggerFactory.getLogger(getClass());
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
logger.info("USERNAME : " + username);
// 根据用户名(数据库)查找用户信息
// 这个User是Spring Security已经实现UserDetails的实例
return new User(username,"123123", AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
}
}

此时可以根据实现的UserDetailService进行自定义的用户认证,内容包括用户名,密码和权限;

处理用户校验逻辑(验证)

密码是否匹配;用户是否冻结;密码是否过期等等;

在具体返回的类型当中UserDetails存在四个boolean的方法,我们可以通过重新方法的方式,自定义不同的校验逻辑;

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
// 用户过期
boolean isAccountNonExpired();
// 账户锁定(冻结)
boolean isAccountNonLocked();
// 密码过期
boolean isCredentialsNonExpired();
// 账户可用('假'删除)
boolean isEnabled();
}

如果是自定义的用户类型,不管是Mybatis还是JPA等数据库获取数据的范式,只需要将自定义的用户实例实现UserDetails即可;

处理密码加密和解密

在数据库中取出的密码以及存入数据库中的密码,是需要通过加密以及解密的过程;

在Spring Security中已有这样的接口,可以具体实现加密和解密,叫做PasswordEncoder

1
2
3
4
5
6
public interface PasswordEncoder {
// 加密
String encode(CharSequence rawPassword);
// 判断加密后的密码以及用户传入的密码是否匹配
boolean matches(CharSequence rawPassword, String encodedPassword);
}

配置一个PasswordEncoder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
public class BroswerSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 表单登录
http.formLogin().and()
.authorizeRequests() // 都需要认证
.anyRequest() // 任何请求
.authenticated(); // 认证后才能访问
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
public class MyUserDetailService implements UserDetailsService {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private PasswordEncoder passwordEncoder;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
logger.info("USERNAME : " + username);
// TODO 根据用户名(数据库)查找用户信息
// 根据查找到的用户信息,判断用户是否被冻结
String password = passwordEncoder.encode("123456");
logger.info("PASSWORD FROM DB : " + password);
// 这个User是Spring Security已经实现UserDetails的实例
return new User(username, password, true, true, true, true, AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
}
}

尝试多次登录,我们发现,默认的123456密码,两次加密结果不一致?

这是Spring Security的强大之处,随机生成一个salt值,与每次的密码进行加密和解密;


个性化用户认证流程

自定义登录页面
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Configuration
public class BroswerSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Override
protected void configure(HttpSecurity http) throws Exception {
// 表单登录
http.formLogin().loginPage("/imooc-signIn.html")
.loginProcessingUrl("/authentication/form")
.and()
// 都需要认证
.authorizeRequests()
// 当访问以下URL,不需要身份认证
.antMatchers("/imooc-signIn.html").permitAll()
// 任何请求
.anyRequest()
// 认证后才能访问
.authenticated()
.and().csrf().disable();;
}
}
其中遇到的问题包括

1.需要配置无须授权的页面,不然在.anyRequest()的配置条件下,自定义的登录页面,也属于请求,将会死循环的页面的重定向;

2.登录页面表单的提交方式使用/authentication/form,需要进行配置,以告诉Spring Secure,需要通过UsernamePassword Authentication Filter进行表单认证;

3.无效的CSRF Token:Spring Security默认提供跨站请求伪造的防护机制,暂时关闭;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Configuration
public class BroswerSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private SecurityProperties securityProperties;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Override
protected void configure(HttpSecurity http) throws Exception {
// 表单登录
http.formLogin().loginPage("/authentication/require")
.loginProcessingUrl("/authentication/form")
.and()
// 都需要认证
.authorizeRequests()
// 当访问以下URL,不需要身份认证
.antMatchers("/authentication/require",securityProperties.getBroswer().getLoginPage()).permitAll()
// 任何请求
.anyRequest()
// 认证后才能访问
.authenticated()
.and().csrf().disable();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@RestController
public class BroswerSecurityController {
private Logger logger = LoggerFactory.getLogger(getClass());
// 请求的缓存
private RequestCache requestCache = new HttpSessionRequestCache();
// 重定向策略
private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Autowired
private SecurityProperties securityProperties;

/**
* 当需要身份认证时,跳转到此处
*/
@RequestMapping("/authentication/require")
@ResponseStatus(code = HttpStatus.UNAUTHORIZED)
public SimpleResponse requireAuthentication(HttpServletRequest request, HttpServletResponse response) throws IOException {
SavedRequest savedRequest = requestCache.getRequest(request, response);
if (savedRequest != null) {
String targetUrl = savedRequest.getRedirectUrl();
logger.info("引发的跳转的URL:" + targetUrl);
if (StringUtils.endsWithIgnoreCase(targetUrl, ".html")) {
redirectStrategy.sendRedirect(request, response, securityProperties.getBroswer().getLoginPage());
}
}
return new SimpleResponse("访问的服务需要服务认证,引导用户到登录页");
}

}
自定义登录成功处理

实现接口AuthenticationSuccessHandler即可

1
2
3
4
5
6
7
8
9
10
11
12
13
@Component("imoocAuthenticationSuccessHandler")
public class ImoocAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private ObjectMapper objectMapper;

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
logger.info("登录成功");
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(authentication));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationSuccessHandler imoocAuthenticationSuccessHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
// 表单登录
http.formLogin().loginPage("/authentication/require")
.loginProcessingUrl("/authentication/form")
.successHandler(imoocAuthenticationSuccessHandler)
...;
}
}

成功登录后,返回的authentication内容包括,根据登录方式不同,包含的信息也是不同的;

自定义登录失败处理
1
2
3
4
5
6
7
8
9
10
11
12
13
@Component("imoocAuthenticationFailHandler")
public class ImoocAuthenticationFailureHandler implements AuthenticationFailureHandler {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private ObjectMapper objectMapper;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
logger.info("登录失败");
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(exception));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationSuccessHandler imoocAuthenticationSuccessHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
// 表单登录
http.formLogin().loginPage("/authentication/require")
.loginProcessingUrl("/authentication/form")
.successHandler(imoocAuthenticationSuccessHandler)
.failureHandler(imoocAuthenticationFailHandler)
...;
}
}

重构

重构代码,使模块同时支持同步和异步的请求,是跳转还是返回JSON;

1
2
3
4
public class BrowserProperties {
private String loginPage = "/imooc-signIn.html";
private LoginType loginType = LoginType.JSON;
}
1
2
3
4
public enum LoginType {
REDIRECT,
JSON
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Component("imoocAuthenticationSuccessHandler")
public class ImoocAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private ObjectMapper objectMapper;
@Autowired
private SecurityProperties securityProperties;

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
logger.info("登录成功");
if (LoginType.JSON.equals(securityProperties.getBroswer().getLoginType())){
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(authentication));
}else{
super.onAuthenticationSuccess(request,response,authentication);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Component("imoocAuthenticationFailHandler")
public class ImoocAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private ObjectMapper objectMapper;
@Autowired
private SecurityProperties securityProperties;

@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
logger.info("登录失败");
if (LoginType.JSON.equals(securityProperties.getBroswer().getLoginType())) {
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(exception));
} else {
super.onAuthenticationFailure(request, response, exception);
}
}
}

根据用户的不同配置,就可以定义具体的返回是重定向页面还是返回JSON格式的数据;


附言

在Core项目中,对配置进行封装;

1
2
3
4
@Configuration
@EnableConfigurationProperties(SecurityProperties.class)
public class SecurityCoreConfig {
}
1
2
3
4
5
6
7
8
9
10
@ConfigurationProperties(prefix = "imooc.security")
public class SecurityProperties {
private BrowserProperties broswer = new BrowserProperties();
public BrowserProperties getBroswer() {
return broswer;
}
public void setBroswer(BrowserProperties broswer) {
this.broswer = broswer;
}
}
1
2
3
4
5
6
7
8
9
public class BrowserProperties {
private String loginPage;
public String getLoginPage() {
return loginPage;
}
public void setLoginPage(String loginPage) {
this.loginPage = loginPage;
}
}